Cou-Cor#
Show imports
%load_ext autoreload
%autoreload 2
import itertools
import os
from functools import cache
from typing import List, Literal, Optional, Tuple
import ms3
import pandas as pd
import plotly.express as px
from dimcat import Pipeline, plotting
import utils
pd.set_option("display.max_rows", 1000)
pd.set_option("display.max_columns", 500)
Show helpers
RESULTS_PATH = os.path.abspath(os.path.join(utils.OUTPUT_FOLDER, "couperin_study"))
os.makedirs(RESULTS_PATH, exist_ok=True)
def make_output_path(
filename: str,
extension=None,
path=RESULTS_PATH,
) -> str:
return utils.make_output_path(filename=filename, extension=extension, path=path)
def save_figure_as(
fig, filename, formats=("png", "pdf"), directory=RESULTS_PATH, **kwargs
):
if formats is not None:
for fmt in formats:
plotting.write_image(fig, filename, directory, format=fmt, **kwargs)
else:
plotting.write_image(fig, filename, directory, **kwargs)
def style_plotly(
fig,
save_as=None,
xaxes: Optional[dict] = None,
yaxes: Optional[dict] = None,
match_facet_yaxes=False,
**layout,
):
layout_args = dict(utils.STD_LAYOUT, **layout)
fig.update_layout(**layout_args)
xaxes_settings = dict(gridcolor="lightgrey")
if xaxes:
xaxes_settings.update(xaxes)
fig.update_xaxes(**xaxes_settings)
yaxes_settings = dict(gridcolor="lightgrey")
if yaxes:
yaxes_settings.update(yaxes)
fig.update_yaxes(**yaxes_settings)
if match_facet_yaxes:
for row_idx, row_figs in enumerate(fig._grid_ref):
for col_idx, col_fig in enumerate(row_figs):
fig.update_yaxes(
row=row_idx + 1,
col=col_idx + 1,
matches="y" + str(len(row_figs) * row_idx + 1),
)
if save_as:
save_figure_as(fig, save_as)
fig.show()
Loading data
Show source
D = utils.get_dataset("couperin_concerts", corpus_release="v2.2")
D_cor = utils.get_dataset("corelli", corpus_release="v2.7")
D
Dataset
=======
{'inputs': {'basepath': None,
'packages': {'couperin_concerts': ["'couperin_concerts.measures' (MuseScoreMeasures)",
"'couperin_concerts.notes' (MuseScoreNotes)",
"'couperin_concerts.expanded' (MuseScoreHarmonies)",
"'couperin_concerts.chords' (MuseScoreChords)",
"'couperin_concerts.metadata' (Metadata)"]}},
'outputs': {'basepath': None, 'packages': {}},
'pipeline': []}
Grouping data
Show source
pipeline = Pipeline(["KeySlicer", "ModeGrouper"])
grouped_D = D.apply_step(pipeline)
grouped_D_cor = D_cor.apply_step(pipeline)
grouped_D
SlicedGroupedDataset
====================
{'inputs': {'basepath': None,
'packages': {'couperin_concerts': ["'couperin_concerts.measures' (MuseScoreMeasures)",
"'couperin_concerts.notes' (MuseScoreNotes)",
"'couperin_concerts.expanded' (MuseScoreHarmonies)",
"'couperin_concerts.chords' (MuseScoreChords)",
"'couperin_concerts.metadata' (Metadata)"]}},
'outputs': {'basepath': None,
'packages': {'features': ["'couperin_concerts.expanded.keyannotations' (KeyAnnotations)"]}},
'pipeline': ['FeatureExtractor', 'KeySlicer', 'ModeGrouper']}
Starting point: DiMCAT’s BassNotes feature
Show source
bass_notes = D.apply_step(pipeline).get_feature("bassnotes")
bass_notes_cor = D_cor.apply_step(pipeline).get_feature("bassnotes")
bass_notes.df
| mc | mn | quarterbeats | duration_qb | mc_onset | mn_onset | timesig | staff | voice | volta | label | pedal | chord | numeral | form | figbass | changes | relativeroot | cadence | phraseend | chord_type | chord_tones | added_tones | root | alt_label | globalkey_is_minor | localkey_is_minor | globalkey_mode | localkey_mode | localkey_resolved | localkey_and_mode | root_roman | relativeroot_resolved | effective_localkey | effective_localkey_resolved | effective_localkey_is_minor | pedal_resolved | chord_and_mode | chord_reduced | chord_reduced_and_mode | applied_to_numeral | numeral_or_applied_to_numeral | intervals_over_bass | intervals_over_root | scale_degrees | scale_degrees_and_mode | scale_degrees_major | scale_degrees_minor | bass_degree | bass_degree_and_mode | bass_degree_major | bass_degree_minor | bass_note_over_local_tonic | globalkey | localkey | bass_note | |||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| mode | corpus | piece | localkey_slice | i | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| major | couperin_concerts | c01n01_prelude | [0.0, 16.0) | 0 | 1 | 0 | 0 | 2.00 | 0 | 1/2 | 4/4 | 1 | 1 | <NA> | G.I{ | <NA> | I | I | <NA> | <NA> | <NA> | <NA> | <NA> | { | M | (0, 4, 1) | () | 0 | <NA> | False | False | major | major | I | I, major | I | NaN | I | I | False | <NA> | I, major | I | I, major | <NA> | I | (M3, P5) | (M3, P5) | (1, 3, 5) | (1, 3, 5), major | (1, 3, 5) | (1, #3, 5) | 1 | 1, major | 1 | 1 | P1 | G | I | 0 |
| 1 | 2 | 1 | 2 | 2.00 | 0 | 0 | 4/4 | 1 | 1 | <NA> | V | <NA> | V | V | <NA> | <NA> | <NA> | <NA> | <NA> | <NA> | M | (1, 5, 2) | () | 1 | <NA> | False | False | major | major | I | I, major | V | NaN | I | I | False | <NA> | V, major | V | V, major | <NA> | V | (M3, P5) | (M3, P5) | (5, 7, 2) | (5, 7, 2), major | (5, 7, 2) | (5, #7, 2) | 5 | 5, major | 5 | 5 | P5 | G | I | 1 | ||||
| 2 | 2 | 1 | 4 | 0.50 | 1/2 | 1/2 | 4/4 | 1 | 1 | <NA> | I6 | <NA> | I6 | I | <NA> | 6 | <NA> | <NA> | <NA> | <NA> | M | (4, 1, 0) | () | 0 | <NA> | False | False | major | major | I | I, major | I | NaN | I | I | False | <NA> | I6, major | I6 | I6, major | <NA> | I | (m3, m6) | (M3, P5) | (3, 5, 1) | (3, 5, 1), major | (3, 5, 1) | (#3, 5, 1) | 3 | 3, major | 3 | #3 | M3 | G | I | 4 | ||||
| 3 | 2 | 1 | 9/2 | 0.50 | 5/8 | 5/8 | 4/4 | 1 | 1 | <NA> | I | <NA> | I | I | <NA> | <NA> | <NA> | <NA> | <NA> | <NA> | M | (0, 4, 1) | () | 0 | <NA> | False | False | major | major | I | I, major | I | NaN | I | I | False | <NA> | I, major | I | I, major | <NA> | I | (M3, P5) | (M3, P5) | (1, 3, 5) | (1, 3, 5), major | (1, 3, 5) | (1, #3, 5) | 1 | 1, major | 1 | 1 | P1 | G | I | 0 | ||||
| 4 | 2 | 1 | 5 | 0.75 | 3/4 | 3/4 | 4/4 | 1 | 1 | <NA> | V(4) | <NA> | V(4) | V | <NA> | <NA> | 4 | <NA> | <NA> | <NA> | M | (1, 0, 2) | () | 1 | <NA> | False | False | major | major | I | I, major | V | NaN | I | I | False | <NA> | V(4), major | V | V, major | <NA> | V | (P4, P5) | (P4, P5) | (5, 1, 2) | (5, 1, 2), major | (5, 1, 2) | (5, 1, 2) | 5 | 5, major | 5 | 5 | P5 | G | I | 1 | ||||
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| minor | couperin_concerts | parnasse_07 | [173.0, 212.0) | 230 | 52 | 52 | 411/2 | 0.25 | 3/8 | 3/8 | 4/4 | 1 | 1 | <NA> | i64 | <NA> | i64 | i | <NA> | 64 | <NA> | <NA> | <NA> | <NA> | m | (1, 0, -3) | () | 0 | <NA> | True | True | minor | minor | i | i, minor | i | NaN | i | i | True | <NA> | i64, minor | i64 | i64, minor | <NA> | i | (P4, m6) | (m3, P5) | (5, 1, 3) | (5, 1, 3), minor | (5, 1, b3) | (5, 1, 3) | 5 | 5, minor | 5 | 5 | P5 | b | i | 1 |
| 231 | 52 | 52 | 823/4 | 0.25 | 7/16 | 7/16 | 4/4 | 1 | 1 | <NA> | iio64 | <NA> | iio64 | ii | o | 64 | <NA> | <NA> | <NA> | <NA> | o | (-4, 2, -1) | () | 2 | <NA> | True | True | minor | minor | i | i, minor | ii | NaN | i | i | True | <NA> | iio64, minor | iio64 | iio64, minor | <NA> | ii | (a4, M6) | (m3, d5) | (6, 2, 4) | (6, 2, 4), minor | (b6, 2, 4) | (6, 2, 4) | 6 | 6, minor | b6 | 6 | m6 | b | i | -4 | ||||
| 232 | 52 | 52 | 206 | 1.00 | 1/2 | 1/2 | 4/4 | 1 | 1 | <NA> | i6 | <NA> | i6 | i | <NA> | 6 | <NA> | <NA> | <NA> | <NA> | m | (-3, 1, 0) | () | 0 | <NA> | True | True | minor | minor | i | i, minor | i | NaN | i | i | True | <NA> | i6, minor | i6 | i6, minor | <NA> | i | (M3, M6) | (m3, P5) | (3, 5, 1) | (3, 5, 1), minor | (b3, 5, 1) | (3, 5, 1) | 3 | 3, minor | b3 | 3 | m3 | b | i | -3 | ||||
| 233 | 52 | 52 | 207 | 1.00 | 3/4 | 3/4 | 4/4 | 1 | 1 | <NA> | V | <NA> | V | V | <NA> | <NA> | <NA> | <NA> | <NA> | <NA> | M | (1, 5, 2) | () | 1 | <NA> | True | True | minor | minor | i | i, minor | V | NaN | i | i | True | <NA> | V, minor | V | V, minor | <NA> | V | (M3, P5) | (M3, P5) | (5, #7, 2) | (5, #7, 2), minor | (5, 7, 2) | (5, #7, 2) | 5 | 5, minor | 5 | 5 | P5 | b | i | 1 | ||||
| 234 | 53 | 53 | 208 | 4.00 | 0 | 0 | 4/4 | 1 | 1 | <NA> | i|PAC} | <NA> | i | i | <NA> | <NA> | <NA> | <NA> | PAC | } | m | (0, -3, 1) | () | 0 | <NA> | True | True | minor | minor | i | i, minor | i | NaN | i | i | True | <NA> | i, minor | i | i, minor | <NA> | i | (m3, P5) | (m3, P5) | (1, 3, 5) | (1, 3, 5), minor | (1, b3, 5) | (1, 3, 5) | 1 | 1, minor | 1 | 1 | P1 | b | i | 0 |
8376 rows × 56 columns
If needed, the localkey_slice intervals can be resolved using this table:
Show source
local_keys = grouped_D.get_feature("KeyAnnotations")
utils.print_heading("Key Segments Couperin")
print(local_keys.groupby("mode").size().to_string())
local_keys_cor = grouped_D_cor.get_feature("KeyAnnotations")
utils.print_heading("\nKey Segments Corelli")
print(local_keys_cor.groupby("mode").size().to_string())
local_keys.head()
Key Segments Couperin
---------------------
mode
major 279
minor 287
Key Segments Corelli
---------------------
mode
major 345
minor 367
| mc | mn | quarterbeats | duration_qb | mc_onset | mn_onset | timesig | staff | voice | volta | label | globalkey_is_minor | localkey_is_minor | globalkey_mode | localkey_mode | localkey_resolved | localkey_and_mode | globalkey | localkey | |||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| mode | corpus | piece | localkey_slice | i | |||||||||||||||||||
| major | couperin_concerts | c01n01_prelude | [0.0, 16.0) | 0 | 1 | 0 | 0 | 16.0 | 0 | 1/2 | 4/4 | 1 | 1 | <NA> | G.I{ | False | False | major | major | I | I, major | G | I |
| [22.5, 32.0) | 22 | 7 | 6 | 45/2 | 9.5 | 1/8 | 1/8 | 4/4 | 1 | 1 | <NA> | V.V{ | False | False | major | major | V | V, major | G | V | |||
| [32.0, 40.0) | 35 | 9 | 8 | 32 | 8.0 | 1/2 | 1/2 | 4/4 | 1 | 1 | <NA> | IV.ii6{ | False | False | major | major | IV | IV, major | G | IV | |||
| [40.0, 47.0) | 41 | 11 | 9 | 40 | 7.0 | 1/2 | 1/2 | 4/4 | 1 | 1 | <NA> | V.V{ | False | False | major | major | V | V, major | G | V | |||
| [47.0, 98.0) | 48 | 13 | 11 | 47 | 51.0 | 1/4 | 1/4 | 4/4 | 1 | 1 | <NA> | I.V65 | False | False | major | major | I | I, major | G | I |
Show helpers
succession_map = dict(
ascending_major={
"1": "2",
"2": "3",
"3": "4",
"4": "5",
"5": "6",
"6": "7",
"7": "1",
},
ascending_minor={
"1": "2",
"2": "3",
"3": "4",
"4": "5",
"5": "#6",
"#6": "#7",
"#7": "1",
},
descending={"1": "7", "2": "1", "3": "2", "4": "3", "5": "4", "6": "5", "7": "6"},
)
def inverse_dict(d):
return {v: k for k, v in d.items()}
predecessor_map = dict(
ascending_major=inverse_dict(succession_map["ascending_major"]),
ascending_minor=inverse_dict(succession_map["ascending_minor"]),
descending=inverse_dict(succession_map["descending"]),
)
def make_precise_preceding_movement_column(df):
"""Expects a dataframe containing the columns bass_degree, preceding_bass_degree, and preceding_movement,"""
preceding_movement_precise = df.preceding_movement.where(
df.preceding_movement != "step", df.preceding_interval
)
expected_ascending_degree = pd.concat(
[
df.loc[["major"], "bass_degree"].map(predecessor_map["ascending_major"]),
df.loc[["minor"], "bass_degree"].map(predecessor_map["ascending_minor"]),
]
)
expected_descending_degree = df.bass_degree.map(predecessor_map["descending"])
preceding_movement_precise = preceding_movement_precise.where(
df.preceding_bass_degree != expected_ascending_degree, "ascending"
)
preceding_movement_precise = preceding_movement_precise.where(
df.preceding_bass_degree != expected_descending_degree, "descending"
)
return preceding_movement_precise
def make_precise_subsequent_movement_column(df):
"""Expects a dataframe containing the columns bass_degree, subsequent_bass_degree, and subsequent_movement,"""
subsequent_movement_precise = df.subsequent_movement.where(
df.subsequent_movement != "step", df.subsequent_interval
)
expected_ascending_degree = pd.concat(
[
df.loc[["major"], "bass_degree"].map(succession_map["ascending_major"]),
df.loc[["minor"], "bass_degree"].map(succession_map["ascending_minor"]),
]
)
expected_descending_degree = df.bass_degree.map(succession_map["descending"])
subsequent_movement_precise = subsequent_movement_precise.where(
df.subsequent_bass_degree != expected_ascending_degree, "ascending"
)
subsequent_movement_precise = subsequent_movement_precise.where(
df.subsequent_bass_degree != expected_descending_degree, "descending"
)
return subsequent_movement_precise
This is the main table of this notebook. It corresponds to the BassNotes features,
with a preceding_ and a subsequent_ copy of each column concatenated to the right.
The respective upward and downward shifts are performed within each localkey group,
leaving first bass degrees with undefined preceding values and last bass degrees without
undefined subsequent values.
Show source
def make_adjacency_table(bass_notes):
preceding = bass_notes.groupby(["piece", "localkey_slice"]).shift()
preceding.columns = "preceding_" + preceding.columns
subsequent = bass_notes.groupby(["piece", "localkey_slice"]).shift(-1)
subsequent.columns = "subsequent_" + subsequent.columns
BN = pd.concat([bass_notes, preceding, subsequent], axis=1)
BN["preceding_iv"] = BN.bass_note - BN.preceding_bass_note
BN["subsequent_iv"] = BN.subsequent_bass_note - BN.bass_note
BN["preceding_interval"] = ms3.transform(
BN.preceding_iv, ms3.fifths2iv, smallest=True
)
BN["subsequent_interval"] = ms3.transform(
BN.subsequent_iv, ms3.fifths2iv, smallest=True
)
BN["preceding_iv_is_step"] = BN.preceding_iv.isin(
(-5, -2, 2, 5)
).where( # +m2, -M2, +M2, -m2
BN.preceding_iv.notna()
)
BN["subsequent_iv_is_step"] = BN.subsequent_iv.isin((-5, -2, 2, 5)).where(
BN.subsequent_iv.notna()
)
BN["preceding_iv_is_0"] = BN.preceding_iv == 0
BN["subsequent_iv_is_0"] = BN.subsequent_iv == 0
BN["preceding_movement"] = (
BN.preceding_iv_is_step.map({True: "step", False: "leap"})
.where(~BN.preceding_iv_is_0, "same")
.where(BN.preceding_iv.notna(), "none")
)
BN["subsequent_movement"] = (
BN.subsequent_iv_is_step.map({True: "step", False: "leap"})
.where(~BN.subsequent_iv_is_0, "same")
.where(BN.subsequent_iv.notna(), "none")
)
BN["preceding_movement_precise"] = make_precise_preceding_movement_column(BN)
BN["subsequent_movement_precise"] = make_precise_subsequent_movement_column(BN)
return BN
BN = make_adjacency_table(bass_notes)
BN_cor = make_adjacency_table(bass_notes_cor)
Show source
ignore_mask = BN.subsequent_interval.isna() | BN.subsequent_interval.duplicated()
interval2fifths = ( # mapping that allows to order the x-axis with intervals according to LoF
BN.loc[~ignore_mask, ["subsequent_interval", "subsequent_iv"]]
.set_index("subsequent_interval")
.iloc[:, 0]
.sort_values()
)
Overview of how the bass moves#
Intervals#
Show source
def plot_bass_movement(BN, corpus_name):
interval_data = pd.concat(
[
BN.groupby("mode").subsequent_interval.value_counts(normalize=True),
BN.groupby(["piece", "mode"])
.subsequent_interval.value_counts(normalize=True)
.groupby(["mode", "subsequent_interval"])
.sem()
.rename("std_err"),
],
axis=1,
).reset_index()
fig = px.bar(
interval_data,
x="subsequent_interval",
y="proportion",
color="mode",
barmode="group",
error_y="std_err",
color_discrete_map=utils.MAJOR_MINOR_COLORS,
labels=dict(subsequent_interval="Interval"),
title=f"Mode-wise proportion of how often a bass note moves by an interval in {corpus_name}",
category_orders=dict(subsequent_interval=interval2fifths.index),
)
style_plotly(fig, f"how_often_a_bass_note_moves_by_an_interval_{corpus_name}")
plot_bass_movement(BN, "Couperin")
plot_bass_movement(BN_cor, "Corelli")
Types of movement#
The values ascending and descending designate stepwise movement within the regola. Only non-chromatic scale
degrees can have these values with the exception of #6 and #7 which are considered diatonic in the context of
this study.
Show source
def plot_movement_types(BN, corpus_name, precise_categories=True):
subsequent_movement = (
"subsequent_movement_precise" if precise_categories else "subsequent_movement"
)
movement_data = pd.concat(
[
BN.groupby("mode")[subsequent_movement].value_counts(
normalize=True, dropna=False
),
BN.groupby(["piece", "mode"])[subsequent_movement]
.value_counts(normalize=True, dropna=False)
.groupby(["mode", subsequent_movement])
.sem()
.rename("std_err"),
],
axis=1,
).reset_index()
movement_data[subsequent_movement] = movement_data[subsequent_movement].fillna(
"none"
)
fig = px.bar(
movement_data,
x=subsequent_movement,
y="proportion",
color="mode",
barmode="group",
error_y="std_err",
color_discrete_map=utils.MAJOR_MINOR_COLORS,
labels={subsequent_movement: "Movement"},
title=f"Mode-wise proportion of a bass note moving in a certain manner in {corpus_name}",
category_orders=dict(subsequent_interval=interval2fifths.index),
)
style_plotly(fig, save_as=f"mode-wise_bass_motion_{corpus_name}")
plot_movement_types(BN, "Couperin")
plot_movement_types(BN_cor, "Corelli")
Sankey diagrams showing movement types before and after each scale degree#
Show helpers
def make_sankey_data(
five_major, color_edges=True, precise=True
) -> Tuple[pd.DataFrame, List[str], List[str]] | Tuple[pd.DataFrame, List[str]]:
preceding_movement = (
"preceding_movement_precise" if precise else "preceding_movement"
)
subsequent_movement = (
"subsequent_movement_precise" if precise else "subsequent_movement"
)
type_counts = five_major["intervals_over_bass"].value_counts()
preceding_movement_counts = five_major[preceding_movement].value_counts()
subsequent_movement_counts = five_major[subsequent_movement].value_counts()
preceding_links = five_major.groupby(
[preceding_movement]
).intervals_over_bass.value_counts()
subsequent_links = five_major.groupby(
[subsequent_movement]
).intervals_over_bass.value_counts()
node_labels = []
label_ids = dict()
for key, node_sizes in (
("preceding", preceding_movement_counts),
("intervals", type_counts),
("subsequent", subsequent_movement_counts),
):
for label in node_sizes.index:
label_id = len(node_labels)
node_labels.append(str(label))
label_ids[(key, label)] = label_id
edge_columns = ["source", "target", "value"]
if color_edges:
node_colors = utils.make_evenly_distributed_color_map(node_labels)
edge_columns.append("color")
links = []
for (prec_mov, iv), cnt in preceding_links.items():
source_id = label_ids.get(("preceding", prec_mov))
target_id = label_ids.get(("intervals", iv))
if color_edges:
edge_color = node_colors[source_id]
links.append((source_id, target_id, cnt, edge_color))
else:
links.append((source_id, target_id, cnt))
for (subs_mov, iv), cnt in subsequent_links.items():
source_id = label_ids.get(("intervals", iv))
target_id = label_ids.get(("subsequent", subs_mov))
if color_edges:
edge_color = node_colors[target_id]
links.append((source_id, target_id, cnt, edge_color))
else:
links.append((source_id, target_id, cnt))
edge_data = pd.DataFrame(links, columns=edge_columns)
if color_edges:
return edge_data, node_labels, node_colors
return edge_data, node_labels
def make_bass_degree_sankey(
BN, corpus, bass_degree: str, mode: Literal["major", "minor"], **layout
):
edge_data, node_labels, node_colors = make_sankey_data(
BN.loc[mode].query(f"bass_degree == '{bass_degree}'")
)
title = f"Motions to and from bass degree {bass_degree} in {corpus}"
fig = utils.make_sankey(
edge_data, node_labels, node_color=node_colors, title=title, **layout
)
return fig
Intervals over bass degree 1#
Major#
Show source
make_bass_degree_sankey(BN, "Couperin", 1, "major")
make_bass_degree_sankey(BN_cor, "Corelli", 1, "major")
Minor#
Show source
make_bass_degree_sankey(BN, "Couperin", 1, "minor")
make_bass_degree_sankey(BN_cor, "Corelli", 1, "minor")
Intervals over bass degree 2#
Major#
make_bass_degree_sankey(BN, "Couperin", 2, "major")
make_bass_degree_sankey(BN_cor, "Corelli", 2, "major")
Minor#
make_bass_degree_sankey(BN, "Couperin", 2, "minor")
make_bass_degree_sankey(BN_cor, "Corelli", 2, "minor")
Intervals over bass degree 3#
Major#
make_bass_degree_sankey(BN, "Couperin", 3, "major")
make_bass_degree_sankey(BN_cor, "Corelli", 3, "major")
Minor#
make_bass_degree_sankey(BN, "Couperin", 3, "minor")
make_bass_degree_sankey(BN_cor, "Corelli", 3, "minor")
Intervals over bass degree 4#
Major#
make_bass_degree_sankey(BN, "Couperin", 4, "major")
make_bass_degree_sankey(BN_cor, "Corelli", 4, "major")
Minor#
make_bass_degree_sankey(BN, "Couperin", 4, "minor")
make_bass_degree_sankey(BN_cor, "Corelli", 4, "minor")
Intervals over bass degree 5#
Major#
make_bass_degree_sankey(BN, "Couperin", 5, "major")
make_bass_degree_sankey(BN_cor, "Corelli", 5, "major")
Minor#
make_bass_degree_sankey(BN, "Couperin", 5, "minor")
make_bass_degree_sankey(BN_cor, "Corelli", 5, "minor")
Intervals over bass degree 6#
Major#
make_bass_degree_sankey(BN, "Couperin", 6, "major")
make_bass_degree_sankey(BN_cor, "Corelli", 6, "major")
Minor (ascending)#
make_bass_degree_sankey(BN, "Couperin", "#6", "minor")
make_bass_degree_sankey(BN, "Corelli", "#6", "minor")
Minor (descending)#
make_bass_degree_sankey(BN, "Couperin", 6, "minor")
make_bass_degree_sankey(BN_cor, "Corelli", 6, "minor")
Intervals over bass degree 7#
Major#
make_bass_degree_sankey(BN, "Couperin", 7, "major")
make_bass_degree_sankey(BN_cor, "Corelli", 7, "major")
Minor (ascending)#
make_bass_degree_sankey(BN, "Couperin", "#7", "minor")
make_bass_degree_sankey(BN, "Corelli", "#7", "minor")
Minor (descending)#
make_bass_degree_sankey(BN, "Couperin", 7, "minor")
make_bass_degree_sankey(BN_cor, "Corelli", 7, "minor")
Explanatory power of the RoO#
Most frequent chord for each bass degree#
Couperin#
Show source
BN.groupby(["mode", "bass_degree"]).intervals_over_bass.apply(
lambda S: S.value_counts().idxmax()
)
mode bass_degree
major #1 (m3, d5, m6)
#4 (m3, d5, m6)
#5 (m3, d5, d7)
1 (M3, P5)
2 (m3, P4, M6)
3 (m3, m6)
4 (M3, P5)
5 (M3, P5)
6 (m3, P5)
7 (m3, d5, m6)
b3 (M3, a5, M7)
b7 (M2, a4, M6)
minor #3 (m3, d5, m6)
#4 (m3, d5, m6)
#6 (m3, m6)
#7 (m3, d5, m6)
1 (m3, P5)
2 (m3, P4, M6)
3 (M3, M6)
4 (m3, P5, M6)
5 (M3, P5)
6 (M3, a4, M6)
7 (M3, M6)
Name: intervals_over_bass, dtype: object
Couperin#
BN_cor.groupby(["mode", "bass_degree"]).intervals_over_bass.apply(
lambda S: S.value_counts().idxmax()
)
mode bass_degree
major #1 (m3, d5, m6)
#2 (m3, m6)
#4 (m3, m6)
#5 (m3, m6)
1 (M3, P5)
2 (m3, P5)
3 (m3, m6)
4 (M3, P5)
5 (M3, P5)
6 (m3, P5)
7 (m3, m6)
b2 (M3, M6)
b3 (M3, M6)
b6 (M3, M6)
b7 (M3, P5)
minor #3 (m3, m6)
#4 (m3, d5, d7)
#6 (m3, m6)
#7 (m3, m6)
1 (m3, P5)
2 (m3, M6)
3 (M3, M6)
4 (m3, P5)
5 (M3, P5)
6 (M3, M6)
7 (M3, M6)
b1 (M3, P5, M6)
b2 (m3, M6)
b5 (M3, M6)
b7 (a4, M6)
Name: intervals_over_bass, dtype: object
Show source
maj = ("M3", "P5")
maj6 = ("m3", "m6")
min = ("m3", "P5")
min6 = ("M3", "M6")
Mm56 = ("m3", "d5", "m6")
Mm34 = ("m3", "P4", "M6")
Mm24 = ("M2", "a4", "M6")
mm56 = ("M3", "P5", "M6")
hdim56 = ("m3", "P5", "M6")
hdim34 = ("M3", "a4", "M6")
regole = dict(
ascending_major=[
("1", maj), # most frequent
("2", Mm34), # most frequent
("3", maj6), # most frequent
("4", mm56), # not most frequent
("5", maj), # most frequent
("6", min6), # not most frequent
("7", Mm56), # most frequent
],
descending_major=[
("1", maj), # same
("7", maj6), # different, not most frequent
("6", Mm34), # different, not most frequent either
("5", maj), # same
("4", Mm24), # different, not most frequent either
("3", maj6), # same
("2", Mm34), # same
],
ascending_minor=[
("1", min), # most frequent
("2", Mm34), # most frequent
("3", min6), # most frequent
("4", hdim56), # most frequent
("5", maj), # most frequent
("#6", maj6), # most frequent
("#7", Mm56), # most frequent
],
descending_minor=[
("1", min), # same
("7", min6), # different, most frequent
("6", hdim34), # different, most frequent
("5", maj), # same
("4", Mm24), # different, not most frequent
("3", min6), # same
("2", Mm34), # same
],
)
Show helpers
@cache
def get_base_df(
basis: Literal[
"major_all", "minor_all", "major_diatonic", "minor_diatonic"
], # minor_diatonic includes 6, #6, 7, #7
query: Optional[str] = None,
):
global BN
try:
mode, selection = basis.split("_")
except Exception:
raise ValueError(f"Invalid keyword for basis: {basis!r}")
base = BN.loc[[mode]]
if selection == "all":
result = base
elif selection == "diatonic":
if mode == "major":
result = base.query("bass_degree in ('1', '2', '3', '4', '5', '6', '7')")
elif mode == "minor":
result = base.query(
"bass_degree in ('1', '2', '3', '4', '5', '6', '#6', '7', '#7')"
)
else:
raise ValueError(f"Unknown keyword for selection: {selection!r}")
if query:
result = result.query(query)
return result
@cache
def get_bass_degree_mask(
basis: Literal[
"major_all", "minor_all", "major_diatonic", "minor_diatonic"
], # minor_diatonic includes 6, #6, 7, #7
bass_degree: str,
query: Optional[str] = None,
):
base = get_base_df(basis, query=query)
return base.bass_degree == bass_degree
@cache
def get_intervals_mask(
basis: Literal[
"major_all", "minor_all", "major_diatonic", "minor_diatonic"
], # minor_diatonic includes 6, #6, 7, #7
intervals: tuple,
query: Optional[str] = None,
):
base = get_base_df(basis, query=query)
return base.intervals_over_bass == intervals
@cache
def get_chord_mask(
basis: Literal[
"major_all", "minor_all", "major_diatonic", "minor_diatonic"
], # minor_diatonic includes 6, #6, 7, #7
bass_degree: str,
intervals: tuple,
query: Optional[str] = None,
):
bass_degree_mask = get_bass_degree_mask(
basis=basis, bass_degree=bass_degree, query=query
)
intervals_mask = get_intervals_mask(basis=basis, intervals=intervals, query=query)
return bass_degree_mask & intervals_mask
@cache
def get_chord_vocabulary_mask(
basis: Literal[
"major_all", "minor_all", "major_diatonic", "minor_diatonic"
], # minor_diatonic includes 6, #6, 7, #7
vocabulary: Tuple[Tuple[str, tuple], ...],
query: Optional[str] = None,
) -> pd.Series:
base = get_base_df(basis, query=query)
mask = pd.Series(False, index=base.index, dtype="boolean")
for bass_degree, intervals in vocabulary:
mask |= get_chord_mask(
basis=basis, bass_degree=bass_degree, intervals=intervals, query=query
)
return mask
def inspect(
basis: Literal[
"major_all", "minor_all", "major_diatonic", "minor_diatonic"
], # minor_diatonic includes 6, #6, 7, #7
vocabulary: Tuple[Tuple[str, tuple], ...],
query: Optional[str] = None,
) -> pd.DataFrame:
base = get_base_df(basis, query=query)
mask = get_chord_vocabulary_mask(basis=basis, vocabulary=vocabulary, query=query)
return base[mask]
def get_vocabulary_coverage(
basis: Literal[
"major_all", "minor_all", "major_diatonic", "minor_diatonic"
], # minor_diatonic includes 6, #6, 7, #7
vocabulary: Tuple[Tuple[str, tuple], ...],
query: Optional[str] = None,
) -> float:
mask = get_chord_vocabulary_mask(basis=basis, vocabulary=vocabulary, query=query)
return mask.sum() / len(mask)
def get_coverage_values(
major_vocabulary: Optional[Tuple[Tuple[str, tuple], ...]] = None,
minor_vocabulary: Optional[Tuple[Tuple[str, tuple], ...]] = None,
**name2query,
) -> pd.Series:
if not (major_vocabulary or minor_vocabulary):
return pd.Series()
results = {}
if major_vocabulary:
results.update(
{
("major", "all"): get_vocabulary_coverage(
"major_all", major_vocabulary
),
("major", "diatonic"): get_vocabulary_coverage(
"major_diatonic", major_vocabulary
),
}
)
for name, query in name2query.items():
results[("major", name)] = get_vocabulary_coverage(
"major_diatonic", major_vocabulary, query=query
)
if minor_vocabulary:
results.update(
{
("minor", "all"): get_vocabulary_coverage(
"minor_all", minor_vocabulary
),
("minor", "diatonic"): get_vocabulary_coverage(
"minor_diatonic", minor_vocabulary
),
}
)
for name, query in name2query.items():
results[("minor", name)] = get_vocabulary_coverage(
"minor_diatonic", minor_vocabulary, query=query
)
result = pd.Series(results, name="proportion")
result.index.names = ["mode", "coverage_of"]
return result
Which proportion of unigrams are “explained” by Campion’s regola#
The percentages are based on different sets of unigrams.
from means before/leading to a bass degree, to means after/following a bass degree.
all: all bass degreesdiatonic: all non-chromatic bass degrees (in minor, the chromatic scale degrees#6and#7are considered diatonic)to_ascending: all diatonic bass degrees that ascend within the regolafrom_ascending: all diatonic bass degrees that are reached by ascending within the regolato_and_from_ascending: all diatonic bass degrees that are reached by ascending within the regola and proceed ascending within the regolato_and_from_either: all diatonic bass degrees whose predecessor and successor are both upper or lower neighbors within the regolato_leap: all diatonic bass degrees followed by a leapto_same: all diatonic bass degrees followed by the same bass degreeetc.
Show source
regola_vocabulary_major = tuple(
set(regole["ascending_major"] + regole["descending_major"])
)
regola_vocabulary_minor = tuple(
set(regole["ascending_minor"] + regole["descending_minor"])
)
features = dict(
to_ascending="subsequent_movement_precise == 'ascending'",
to_descending="subsequent_movement_precise == 'descending'",
to_either="subsequent_movement_precise == ['ascending', 'descending']",
to_leap="subsequent_movement == 'leap'",
to_same="subsequent_movement == 'same'",
last_notes="subsequent_movement == 'none'",
from_ascending="preceding_movement_precise == 'ascending'",
from_descending="preceding_movement_precise == 'descending'",
from_either="preceding_movement_precise == ['ascending', 'descending']",
from_leap="preceding_movement == 'leap'",
from_same="preceding_movement == 'same'",
first_notes="preceding_movement == 'none'",
to_and_from_ascending="subsequent_movement_precise == 'ascending' & preceding_movement_precise == 'ascending'",
to_and_from_descending="subsequent_movement_precise == 'descending' & preceding_movement_precise == 'descending'",
to_and_from_either="subsequent_movement_precise == ['ascending', 'descending'] & "
"preceding_movement_precise == ['ascending', 'descending']",
to_and_from_leap="subsequent_movement == 'leap' & preceding_movement == 'leap'",
to_and_from_same="subsequent_movement == 'same' & preceding_movement == 'same'",
)
regola_coverage = get_coverage_values(
regola_vocabulary_major, regola_vocabulary_minor, **features
)
regola_coverage
mode coverage_of
major all 0.651522
diatonic 0.662083
to_ascending 0.738881
to_descending 0.723051
to_either 0.731538
to_leap 0.652388
to_same 0.403084
last_notes 0.827957
from_ascending 0.680057
from_descending 0.787728
from_either 0.730000
from_leap 0.665669
from_same 0.436123
first_notes 0.726277
to_and_from_ascending 0.768293
to_and_from_descending 0.852814
to_and_from_either 0.824176
to_and_from_leap 0.624314
to_and_from_same 0.444444
minor all 0.626947
diatonic 0.638242
to_ascending 0.765468
to_descending 0.714504
to_either 0.740741
to_leap 0.643806
to_same 0.363229
last_notes 0.629371
from_ascending 0.705036
from_descending 0.714504
from_either 0.709630
from_leap 0.643243
from_same 0.461883
first_notes 0.684783
to_and_from_ascending 0.811321
to_and_from_descending 0.745174
to_and_from_either 0.783835
to_and_from_leap 0.620690
to_and_from_same 0.409091
Name: proportion, dtype: float64
Comparing the regola against all “top k” vocabularies#
Campion’s regola comprises 10 different chords for both major and minor. For comparison, its values are shown at point 10.5 on the x-axis. The lower two plots show how many unigrams are covered by individual chords. Hover over the points to see the corresponding chords.
Show helpers
def make_coverage_plot_data(
include_singular_vocabularies=True, **features
) -> pd.DataFrame:
all_chords = BN[["bass_degree", "intervals_over_bass"]].apply(tuple, axis=1)
chord_ranking = all_chords.groupby("mode").value_counts(normalize=True)
major_ranking, minor_ranking = (
chord_ranking.loc["major"],
chord_ranking.loc["minor"],
)
major_vocab, minor_vocab = [], []
results = {}
for i, (maj_chord, min_chord) in enumerate(
itertools.zip_longest(major_ranking.index, minor_ranking.index), 1
):
if maj_chord:
major_vocab.append(maj_chord)
if min_chord:
minor_vocab.append(min_chord)
key = ("cumulative", i) if include_singular_vocabularies else i
values = get_coverage_values(tuple(major_vocab), tuple(minor_vocab), **features)
chord = pd.Series(str(maj_chord), index=values.index, name="chord")
chord.loc["minor"] = str(min_chord)
results[key] = pd.concat([values, chord], axis=1)
if not include_singular_vocabularies:
continue
single_maj_vocab = (maj_chord,) if maj_chord else None
single_min_vocab = (min_chord,) if min_chord else None
values = get_coverage_values(single_maj_vocab, single_min_vocab, **features)
results[("single", i)] = pd.concat([values, chord], axis=1)
index_levels = ["vocabulary", "rank"] if include_singular_vocabularies else ["rank"]
return pd.concat(results, names=index_levels)
Show source
result = make_coverage_plot_data(**features)
regola_results = pd.concat(
{("cumulative", 10.5): regola_coverage}, names=["vocabulary", "rank"]
).to_frame()
regola_results.loc[:, "chord"] = "regola"
result = pd.concat(
[
regola_results,
result,
]
).sort_index()
Show source
fig = px.line(
result.reset_index(),
x="rank",
y="proportion",
color="coverage_of",
facet_col="mode",
facet_row="vocabulary",
hover_name="chord",
log_x=True,
title="How many unigrams are covered by each top-k vocabulary",
)
style_plotly(
fig,
match_facet_yaxes=True,
height=1500,
legend=dict(
orientation="h",
),
)
In order to inspect these plots you will want to hide traces. Click on a legend item to toggle it, double-click on an item to toggle all others.
In order to inspect these plots you will want to hide traces. Click on a legend item to toggle it, double-click on an item to toggle all others.